feat(types): declare RichtextFieldMetadata for the widget's third registry key - #8373
Merged
Merged
Conversation
…istry key `markdown`, `html` and `richtext` are one widget (objectui#5498). Two of the three carried an exported metadata type; `richtext` carried none, so the runtime served it by structure while an author could not write its metadata under an annotation at all. The only way to write one was `as unknown as MarkdownFieldMetadata` — a deliberate cast in this repo's own pin test that was the gap's sole evidence. `RichtextFieldMetadata` is now exported from `@object-ui/types` and joins the `FieldMetadata` union, so a richtext field's metadata is a typed literal and narrows out of the union on `type`. The cast and its comment are gone, and the surrounding docblock is re-pointed so the gap cannot be rediscovered. The member's shape was derived from what `RichTextField` reads on the `richtext` path, not copied from the two siblings: `type`, `rows`, `placeholder`, `mobile_fullscreen` and `label` (the last three already sit on `BaseFieldMetadata`), with the readonly branch's cell renderers contributing no metadata key. `max_length` is declared on the measurement that `richtext` is symmetric with `markdown`/`html` at the `@objectstack/spec` 17.3.0 authoring boundary, not on the siblings having it. Additive only: nothing is removed or narrowed, `RichTextField` is unchanged, and metadata previously written through a cast keeps compiling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX
…htext-field-metadata
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…ew union member Comment only — no behaviour, no read site, no signature changes. The paragraph above `richField` said "the third key, `richtext`, has no union member of its own". This branch is what makes that false, so the correction belongs to this diff rather than to a follow-up: the ruling's stated reason for deleting the pin test's comment was that the gap must not stay rediscoverable, and the same sentence sat four lines from where a reader of the widget looks. The cast itself is unchanged and stays correct. It names two of the three members because it never had to discriminate: every key this widget consumes off the carrier is declared on all three, so the two named already admit each read below. The comment now says that outright instead of implying the third key is undeclarable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This was referenced Sep 7, 2026
…te max_length's real reader Contract-review rework on #7083 (three items, all reproducible). R1 — content/docs/fields/rich-text.mdx said `markdown` and `html` are "two field types served by one widget", "each has its own exported metadata type", and "there is no combined 'rich text' metadata type"; the page never mentioned the `richtext` key. Declaring RichtextFieldMetadata is what made that false. The Field Schema section now names all three types, the fence carries a RichtextFieldMetadata literal, and Editor Modes lists the third mode. Follows the #7061 precedent, which updated this same page for the analogous change. R2 — the member's TSDoc credited "richtext symmetric with both on every axis that can be measured". That is false: ObjectForm forwards max_length to the HTML maxlength attribute for text|textarea|markdown|html only, and EmbeddableForm's DEFAULT_MAX_LENGTH caps markdown/html only — richtext is absent from both. The spec-symmetry reading is also non-discriminating for this key (FieldSchema answers identically for every field type, `text` included). The real reader is buildValidationRules — generic, no field-type gate, called on every field by ObjectForm and sectionFields, spread into the RHF rules and localized — so max_length on a richtext field IS enforced at submit. Same correction in the changeset body. The pin citation is re-pointed: richtext-field-metadata-7083.test.tsx pins `maxLength` admitted and `max_length` refused; `rows` admitted is pinned by select-option-spec-extension-7014.test.ts. The pin test's own docblock carried the same false rationale and is corrected with it — comment-only, proven: stripped of comments the file hashes identically before and after (68 non-comment lines, unchanged). No change to the member's shape, the union, the barrel, or any test's logic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX
…ssue-7083-richtext-field-metadata
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #7083
Executes the maintainer's ruling of 2026-09-07 (director seat, decision batch #71, comment 5565745438): the three registry keys one widget serves each get an exported metadata type.
RichtextFieldMetadatais added to@object-ui/types, joins theFieldMetadataunion, and the deliberateas unknown as MarkdownFieldMetadatainpackages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx— with the comment above it — is gone.Contract-review rework — round 2
A
CONTRACT_REVIEW_TIERreview returned FAIL with three reproducible items. The member's shape was judged correct and evidenced, and is untouched by this round: no change to the member, the union, the barrel, or any test's logic. What changed is one docs page and three pieces of prose that were false.R1 — this diff had made a published docs page false
content/docs/fields/rich-text.mdxsaidmarkdownandhtml"are two field types served by one widget", that "each has its own exported metadata type", and that "there is no combined 'rich text' metadata type". The page never mentioned therichtextkey at all. DeclaringRichtextFieldMetadatais exactly what made those sentences false, and leaving them would have restarted the rediscovery loop the ruling exists to end.Corrected by following the objectui#7061 precedent, which updated this same page for the analogous
rowschange:markdownandhtmlare two field types served by one widget, and each has its own exported metadata type —MarkdownFieldMetadataandHtmlFieldMetadata… there is no combined "rich text" metadata type."markdown,htmlandrichtextare three field types served by one widget, and each has its own exported metadata type —MarkdownFieldMetadata,HtmlFieldMetadataandRichtextFieldMetadata… there is no combined "rich text" metadata type."tsfencereleaseNotesandemailBodyarticleBody: RichtextFieldMetadataliteral withtype/name/label/rows/max_lengthrowsparagraphrichtextis (stores HTML, reads through the same display pipeline ashtml, objectui#5452), when it became declarable, and whatmax_lengthon it actually doestype: 'richtext')The Editor Modes entry is the same correction applied to the same page: the section enumerates the widget's modes and stopped at two. The seat's authorisation for this file-surface widening covers
content/docs/fields/rich-text.mdxand nothing else; no other doc page is touched, andcontent/docs/releases/is untouched.R2 — the published TSDoc stated a false claim, for the wrong reason, citing the wrong pin
Three sub-items, all in
RichtextFieldMetadata'smax_lengthsection ofpackages/types/src/field-types.tsand mirrored in the changeset body.1. The "symmetric on every measurable axis" claim is dropped — it is false. Located by content, not by line:
ObjectFormforwardsmax_lengthto the HTMLmaxlengthattribute underif (field.type === 'text' || field.type === 'textarea' || field.type === 'markdown' || field.type === 'html'), andEmbeddableForm'sDEFAULT_MAX_LENGTHrecord liststext,email,url,phone,textarea,markdown,html.richtextis absent from both.2. The real reader is now stated, and it is checkable. The spec-symmetry argument is non-discriminating for this key:
FieldSchemaanswers identically for every field type,textincluded, onmaxLengthandmax_length, so it says nothing aboutrichtextversus its siblings. The reader the round-1 derivation missed isbuildValidationRulesinpackages/fields/src/index.tsx:Generic — no field-type gate anywhere in the function. It is applied to every field by
ObjectForm(validation: buildValidationRules(field)) and by the identical line insectionFields.ts; the form renderer spreads the result into the react-hook-formrulesobject and localizes themaxLengthentry. ⇒max_lengthon arichtextfield is enforced at submit. That is the checkable reason the key is declared, and it now replaces the symmetry argument in both the docblock and the changeset. The two sites that do not reachrichtextare stated alongside it, so the boundary cannot be re-derived as symmetry.3. The pin citation is re-pointed. The docblock credited
richtext-field-metadata-7083.test.tsxfor three facts; that file pins only two of them.maxLengthadmittedrichtext-field-metadata-7083.test.tsxmax_lengthrefused by namerichtext-field-metadata-7083.test.tsxrowsadmittedrichtext-field-metadata-7083.test.tsxpackages/types/src/__tests__/select-option-spec-extension-7014.test.ts, its "FieldSchema declaresrowson the four multiline editor types" groupCarried into the pin test's own docblock. That file repeated the same false rationale — "Putting it on the new member was a decision, and it rests on this reading rather than on the two siblings having it" — while being cited by the corrected docblock, so it is corrected with it. Comment-only, proven: stripped of every block and line comment, the file hashes
c1d01d39c24f2ada70c58b52b8fa829574594315875f5d3ffc17bcccc65b74bfboth before and after, at 68 non-comment lines either way. No assertion, fixture ordescribebody is touched.R3 — the PR body contradicted its own diff
Since
de98ffd3this body was false in three places. The reviewer independently verified that theRichTextField.tsxedit is comment-only (stripped-of-//hashes identical, 0 non-comment lines changed, and the comment text appears nowhere in builtdist/), so the correction is wording, not code.de98ffd3; the note now records that, and that the edit is comment-onlypackages/fieldschanges are test-only"packages/fieldschanges are test-only plus one comment-only edit, proven to ship nothing"All three are corrected in place below. The read-site table has also lost its line numbers, which had drifted with the merge; it now anchors on the expressions themselves.
What was missing
markdown,htmlandrichtextare one widget (#5498). Two of the three carried an exported metadata type;richtextcarried none, so the runtime served it happily by structure while an author could not write its metadata under an annotation at all. The state was neither a member nor a recorded alias — a silent gap whose only evidence was a cast in this repo's own pin test. That asymmetry is why the gap had to be rediscovered to be seen, and #7083 is itself that rediscovery.So the comment goes with the cast, and the surrounding docblock in that file is re-pointed: the next reader should not be able to find the gap again.
The read set was DERIVED, not copied
The ruling's load-bearing sentence is "do not copy 'probably the same'". Every key was read off
packages/fields/src/widgets/RichTextField.tsxon therichtextpath. That file carries a comment-only edit in this change — the stale comment described in 验收备注 1 — and ships nothing: its executable text is byte-identical, and the reviewer confirmed the comment text appears nowhere in the builtdist/.RichTextField.tsxtyperesolveRichTextFieldTypereadsfield.typeand strips afield:prefix'richtext'on the new memberrowsconst rows = richField?.rows || 8mobile_fullscreenBoolean(richField?.mobile_fullscreen)BaseFieldMetadataplaceholderrichField?.placeholder || t(...)BaseFieldMetadatalabelFullscreenFieldEditorBaseFieldMetadataThe readonly branch hands
fieldwhole to theRICH_TEXT_CELL_RENDERERSentry for the type; both renderers inrichTextDisplay.tsxdestructurevalueonly, so the display half contributes no metadata key.typeis also the only thing that differs between the three keys at runtime — it selects the display pipeline (richtextreads through the HTML renderer, #5452) and the format label. Nothing else in the widget branches on it.The one key that needed a decision
MarkdownFieldMetadataandHtmlFieldMetadataare byte-identical to each other apart from theirtypeliteral:type+max_length?: number+rows?: numberoverBaseFieldMetadata. So the cross-check produced exactly one open question —max_length, the one key on the siblings thatRichTextFielddoes not read.It is declared, because a live reader outside the widget reads it on every field:
buildValidationRules(see R2 above) compiles it into a submit-time react-hook-form rule with no field-type gate, and both form producers call it on every field they build. Amax_lengthwritten on arichtextfield is therefore enforced when the form is submitted.maxlengthattribute, and it gets no default cap inEmbeddableForm— both of those enumerate field types and omitrichtext. Those two omissions predate this PR, are not this PR's, and are not touched here.Separately — and not the reason for the key — the authoring boundary at
@objectstack/spec17.3.0 answers identically for all three of the field types this widget serves. That reading is non-discriminating (every field type answers the same way,textincluded) but is pinned anyway so the docblock cannot rot into a false canonical claim about the spelling:markdownhtmlrichtextmaxLength: 50(the spec's own spelling)max_length: 50(objectui legacy spelling)rows: 4is admitted for all three too, and that half is pinned inpackages/types/src/__tests__/select-option-spec-extension-7014.test.ts, not in this PR's file.The pin has two halves, and both are non-vacuous
packages/fields/src/widgets/__tests__/richtext-field-metadata-7083.test.tsx:as, so TypeScript's excess-property check judges every key, and it is assigned toFieldMetadatato pin the union membership the ruling granted plus the narrowing ontype. Measured, not assumed, that this leg actually runs:packages/fields'stype-checkscript istsc --noEmit && tsc -p tsconfig.test.json, andtsc -p tsconfig.test.json --listFilesnames both this file andRichTextField.rows.test.tsx. On the pre-change tree the file does not compile at all — there is no member to annotate against — which is what makes this leg real rather than decorative. A pin proving only the render would still have passed with the cast in place, i.e. in the state this card ends.rows,placeholder,mobile_fullscreenandlabelare each asserted at the DOM, against a control fixture with all the optional keys absent at once, so a green reads "the widget consumed the declared key" and never "the default happened to match".Scope
Additive only. Nothing is removed or narrowed;
richtextmetadata previously written through a cast keeps compiling;RichTextField's behaviour is untouched — it already served all three keys, and this change only gives the third one a face. Round-1 downstream sweep:turbo run type-check --filter='...@object-ui/types'— 77/77 tasks green, so widening theFieldMetadataunion breaks no consumer.packages/fieldschanges are test-only plus one comment-only edit (RichTextField.tsx), proven to ship nothing, so the changeset grades@object-ui/typesminor and nothing else.Verification — round 2
Merged
origin/mainin first (9b1b105d, merge, never rebase; conflict-free) because the PR'smergeable_statereadunknownon two consecutive API reads rather thanclean. Every reading below is from after that merge, on the pushed head. Exit codes captured before any pipe (cmd > log 2>&1; EXIT=$?), each verdict read from the gate's own printed line.pnpm --filter @object-ui/types type-checktsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json— no diagnosticspnpm --filter @object-ui/types build✓ dist completeness: 1 package(s) complete (124 emitted files verified)pnpm --filter @object-ui/fields type-checknode scripts/check-changeset-presence.mjs5 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)node scripts/check-doc-snippet-types.mjs(check:doc-snippets)Semantic phase: 632 of 632 block(s) judged, 0 failed·240 coverednode scripts/check-doc-component-types.mjs(check:doc-types)Every documented component type is registered.check:doc-fencesevery TypeScript block in 227 document(s) is fenced ts/tsx/typescriptcheck:doc-examplesEvery covered @example compiles, or fails exactly as its ledger row declares.docs:check-linksLinks are valid across 17 scan roots.check:control-bytesOK (scanned 6645 tracked text file(s); skipped 85 binary)vitest run— the three pin filesTest Files 3 passed (3)·Tests 56 passed (56)Beyond the gates, a control-byte self-scan over the four touched paths (
grep -naPover the C0 range plus DEL) found none.check:doc-snippetscompiles fences against builtdist/*.d.ts, so its closure (--build-filter, 34 packages) was rebuilt after the merge before the reading above was taken — an unbuilt tree exits 2 there and is not a verdict about any document.The new fence is proven compiled, not assumed
The gate reports a block count, not which block is yours, so a red/green pair was measured rather than inferred. Mutation and restore both anchored on the target text and verified on disk:
toolbar: ['bold']injected into thearticleBody: RichtextFieldMetadataliteral. On-disk proof: injected text present exactly 1 time; blob8bfe7787…→49956cd3…(a no-op mutation would have failed the run loudly).check-doc-snippet-types.mjsexit 1, naming the file:[semantic] content/docs/fields/rich-text.mdx:52:3 TS2353: Object literal may only specify known properties, and 'toolbar' does not exist in type 'RichtextFieldMetadata'.·Semantic phase: 632 of 632 block(s) judged, 1 failed.git checkout HEAD -- content/docs/fields/rich-text.mdx(never a baregit checkout --; the implementation was committed first, soHEADcarries it). Proven by state, not by exit code: working blob back to8bfe7787…, identical to theHEADblob, andgit diff HEADempty. The script carried atrap … EXIT INT TERMrestore with an absolute repo-root path throughout.Semantic phase: 632 of 632 block(s) judged, 0 failed.⇒ the gate does read the new
RichtextFieldMetadataliteral, and it can fail on it.验收备注
Observations. None is filed as a card by this seat.
RichTextField.tsx's metadata-face comment was stale after the member landed, and it was corrected — inde98ffd3, not left. Its old text said the third key "has no union member of its own"; it now namesRichtextFieldMetadataand explains why the cast below it still names only two of the three. The edit is comment-only: stripped-of-comment hashes identical, 0 non-comment lines changed, and the comment text appears nowhere in builtdist/. The round-1 body claimed the file was untouched; that claim is what R3 corrected.rowsdocblocks onMarkdownFieldMetadataandHtmlFieldMetadatastill describe the@objectstack/spec17.2.0 boundary (whererowswas refused by name); at 17.3.0 it is admitted. That prose is When the @objectstack/spec pin moves to 17.3: PR #7510 / #7520's comment "repairs" become FALSE —rowsandoptions[].descriptionare now declared keys #7635's declared surface, so this PR states the 17.3.0 reading forrichtextonly and does not correct the neighbours.richtextis missing from three pre-existing enumerations.ObjectForm'smaxlength-attribute forwarding,EmbeddableForm'sDEFAULT_MAX_LENGTH, anddesigner.ts'sDESIGNER_FIELD_TYPESeach list field types and omitrichtext, even though one widget serves all three. They are real, they predate this PR, and touching any of them would widen it. The dispatching PM seat is filing the finding card; this seat files nothing and changes nothing there.Review
Clause-② applies (a new published type-face member), so
needs:contract-reviewis hung on this PR as well as on the card, and stays — this round does not clear it. This PR stays draft, is not enqueued, and has no auto-merge armed; parking green is the intended end state until a freshCONTRACT_REVIEW_TIERreview passes. No label was added or removed and no review was submitted by this seat.Authored by the
domain:spec@ objectui execution seat, sessionhttps://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX— kept in prose as a code span because aPATCHto this body downgrades a link-form attribution footer and drops the session reference.